iT邦幫忙

2022 iThome 鐵人賽

DAY 28
1
Software Development

從 Node.js 開發者到量化交易者:打造屬於自己的投資系統系列 第 28

Day 28 - 自動化下單:富果行情與交易 API 的整合應用

  • 分享至 

  • xImage
  •  

在本系列文即時行情監控的主題,我們實作了 Monitor 應用伺服器,以富果行情 API 結合 LINE Notify 為例,實現股票即時行情監控系統。在前一天,我們也實作出 Trader 應用伺服器,可以透過富果交易 API 進行下單委託、帳務查詢等功能,實現一個程式交易系統。今天我們要整合 Monitor 與 Trader 應用伺服器,透過富果行情與交易 API,打造一個自動化下單的服務。

Monitor x Trader 應用程式伺服器概觀

為了理解整個自動化下單的運作流程,我們先描繪出 Monitor 與 Trader 應用伺服器的系統環境圖:

https://ithelp.ithome.com.tw/upload/images/20220928/20150150IA2BVVnhLS.png

在 Monitor 與 Trader 應用伺服器,主要包含以下元件:

  • Monitor API:提供 REST 風格的 HTTP API,使用者可以向 Monitor 應用程式發送請求,建立觸價委託設定。
  • Monitor Service:處理 Monitor API 請求,同時與 Fugle Realtime 建立連線接收即時行情報價,並判斷是否觸發委託設定,透過 LINE Notify 請求發送推播訊息。
  • MongoDB:作為數據持久化的資料庫,儲存使用者的觸價委託設定。
  • Redis:作為暫存觸價委託設定快取,加速處理即時行情的速度。
  • Trader API:提供 REST 風格的 HTTP API,使用者可以向 Trader 應用程式發送請求,進行下單委託、並查詢成交明細、帳戶庫存、交割資訊等。
  • Trader Service:整合富果交易 SDK 並處理 Trader API 請求,當收到委託及成交回報時,透過 LINE Notify 請求發送推播訊息。

在 Monitor 與 Trader 應用伺服器處理資料的流程可以分成三大部分,包含使用者向 Monitor API 請求建立觸價委託、 Monitor Service 接收 Fugle Realtime 即時行情的資料處理,以及 Monitor Service 向 Trader API 下達觸價委託指令。

使用者向 Monitor API 請求建立觸價委託:

  • ① User → Monitor API:使用者向 Monitor API 發送請求,建立觸價委託設定。
  • ② Monitor API → Monitor Service:Monitor Service 處理使用者向 Monitor API 發送的請求。
  • ③ Monitor Service → MongoDB:Monitor Service 將觸價委託設定儲存至 MongoDB 資料庫。
  • ④ Monitor Service → Redis:Monitor Service 將觸價委託設定寫入 Redis 快取。

Monitor Service 接收 Fugle Realtime 即時行情:

  • ① Exchange → Fugle Realtime:Fugle Realtime 接收來自交易所的即時行情報價。
  • ② Fugle Realtime → Monitor Service:Monitor Service 與 Fugle Realtime 建立 WebSocket 連線,在交易日盤中會接收最新的即時行情報價。
  • ③ Monitor Service → Redis:當 Monitor Service 收到監控股票的最新報價時,會與 Redis 快取的觸價委託設定做比對,判斷是否觸發下單委託(進行 Monitor Service 向 Trader API 請求下單委託 的流程)。
  • ④ Monitor Service → LINE Notify:當觸價委託條件觸發時,Monitor Service 會向 LINE Notify 請求發送推播訊息。
  • ⑤ LINE Notify → User:LINE Notify 收到推播請求後,會將訊息發送給使用者。
  • ⑥ Monitor Service → MongoDB:下達觸價委託後,Monitor Service 會更新 MongoDB 資料庫的觸價委託設定並將其標示為已觸發。

Monitor Service 向 Trader API 請求下單委託:

  • ① Monitor Service → Trader API:Monitor Service 向 Trader API 發送請求,進行下單委託。
  • ② Trader API → Trader Service:Trader Service 處理 Monitor Service 向 Trader API 發送的請求。
  • ③ Trader Service → FugleTrade:Trader Service 向富果交易系統請求下單委託。
  • ④ FugleTrade → E.SUN Securities:富果交易系統向玉山證券驗章並轉送下單委託。
  • ⑤ E.SUN Securities → Exchange:玉山證券向交易所發出下單委託電文。
  • ⑥ Exchange → E.SUN Securities:玉山證券收到交易所的委託或成交回報電文。
  • ⑦ E.SUN Securities → FugleTrade:FugleTrade 接收來自交易所及玉山證券的委託或成交回報。
  • ⑧ FugleTrade → Trader Service:Trader Service 與富果交易系統 Streamer 連線,接收委託及成交回報。
  • ⑨ Trader Service → LINE Notify:當收到委託或成交回報時,Trader Service 會向 LINE Notify 請求發送推播訊息。
  • ⑩ LINE Notify → User:LINE Notify 收到推播請求後,會將訊息發送給使用者。

調整 Trader 應用程式

為了讓 Monitor 應用程式存取 Trader 應用程式的服務,我們會將 Trader 應用伺服器調整為混合式應用程式(hybrid application)既可以監聽 HTTP 請求,又可以使用連接的微服務(microservice)。

Nest Framework 支持微服務(microservice)架構風格的開發,在 Nest 中,微服務是使用與 HTTP 不同的傳輸層(transport)的應用程式。Nest 支援幾種內建的傳輸層實現,稱為傳輸器(transporters),它們負責在不同的微服務實體之間傳輸訊息。其中 Nest 的 Redis 傳輸器是利用 Redis 的 Pub/Sub 特性,實現在不同服務之間的訊息傳遞。由於我們已經安裝 Redis,所以我們就使用 Redis 作為微服務傳輸器。

為了開始建立微服務,首先安裝需要的套件:

$ npm install --save @nestjs/microservices

註:在目前最新的 Nest v9 版本中,已經將 Redis 傳輸器的依賴改為使用 ioredis 套件,在本系列文前面的主題已經安裝過該套件。如果是使用 Nest v8 以前的版本,則需要安裝 redis 套件作為 Redis 傳輸器的依賴。

安裝完成後,先在專案目錄下開啟 .env 檔案,加入 Redis 連線位址的環境變數設定:

REDIS_HOST=
REDIS_PORT=

設定環境變數後,開啟 apps/trader/src/main.ts 檔案,匯入 @nestjs/microservices 模組,使用 connectMicroservice() 方法連接微服務,並設定 Redis 作為微服務傳輸器。

import { NestFactory } from '@nestjs/core';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.connectMicroservice<MicroserviceOptions>({
    transport: Transport.REDIS,
    options: {
      host: process.env.REDIS_HOST,
      port: +process.env.REDIS_PORT,
    },
  });

  await app.startAllMicroservices();
  await app.listen(3001);
}
bootstrap();

在測試期間,為了避免和 monotor 應用程式使用的 port 衝突,我們將 trader 應用程式的 port 改為 3001

然後開啟 apps/trader/src/trader/trader.controller.ts 檔案,在 TraderController 加入 handleOrder() 方法,處理微服務請求:

import { Controller, Get, Post, Put, Delete, Body, Param, Query } from '@nestjs/common';
import { EventPattern, Payload } from '@nestjs/microservices';
import { TraderService } from './trader.service';
import { PlaceOrderDto } from './dto/place-order.dto';
import { ReplaceOrderDto } from './dto/replace-order.dto';
import { GetTransactionsDto } from './dto/get-transactions.dto';

@Controller('trader')
export class TraderController {
  constructor(private readonly traderService: TraderService) {}

  ...

  @EventPattern('place-order')
  async handleOrder(@Payload() placeOrderDto) {
    return this.traderService.placeOrder(placeOrderDto);
  }
}

handleOrder() 方法使用 @EventPattern() 裝飾器,定義基於事件(event-based)的微服務介面。當收到請求後,Trader 應用程式就會執行下單委託。

完成 Trader 應用程式的微服務後,我們要在 libs/common 定義常數代表 Trader 應用程式提供的微服務,方便其他 Nest 應用程式引用。在 libs/common/src 目錄下建立 constants.ts 檔案,並開啟該檔案加入:

export const TRADER_SERVICE = 'TRADER_SERVICE';

然後開啟 libs/common/src/index.ts 檔案,將 constants.ts 匯出:

export * from './constants';

在其他 Nest 應用程式中就可以透過以下方式引用 TRADER_SERVICE

import { TRADER_SERVICE } from '@speculator/common';

調整 Monitor 應用程式

為了在 Monitor 應用程式調用 Trader 應用程式的微服務,請開啟 apps/trader/src/monitor/monitor.module.ts 檔案,我們在 MonitorModule 匯入 ClientsModule 註冊微服務設定:

import { Module } from '@nestjs/common';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { MongooseModule } from '@nestjs/mongoose';
import { FugleRealtimeModule } from '@fugle/realtime-nest';
import { TRADER_SERVICE } from '@speculator/common';
import { Monitor, MonitorSchema } from './monitor.schema';
import { MonitorRepository } from './monitor.repository';
import { MonitorService } from './monitor.service';
import { MonitorController } from './monitor.controller';

@Module({
  imports: [
    ClientsModule.registerAsync([{
      name: TRADER_SERVICE,
      useFactory: () => ({
        transport: Transport.REDIS,
        options: {
          host: process.env.REDIS_HOST,
          port: +process.env.REDIS_PORT,
        },
      }),
    }]),
    MongooseModule.forFeature([
      { name: Monitor.name, schema: MonitorSchema },
    ]),
    FugleRealtimeModule.registerAsync({
      useFactory: () => ({
        apiToken: process.env.FUGLE_REALTIME_API_TOKEN,
      }),
    }),
  ],
  providers: [MonitorRepository, MonitorService],
  controllers: [MonitorController],
})
export class MonitorModule { }

定義 Monitor 觸價委託設定

我們要在 Monitor 應用程式新增觸價委託 API,為了實現這個功能,我們需要調整 MonitorSchema。開啟 apps/monitor/src/monitor/monitor.schema.ts 檔案,在 MonitorSchema 新增 order 欄位,代表下單委託的設定:

import { Prop, raw, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose';

export type MonitorDocument = Monitor & Document;

@Schema({ timestamps: true })
export class Monitor {
  @Prop()
  symbol: string;

  @Prop()
  type: string;

  @Prop()
  value: string;

  @Prop(raw({
    title: { type: String },
    message: { type: String },
  }))
  alert: Record<string, string>;

  // 新增 order 欄位
  @Prop(raw({
    stockNo: { type: String },
    buySell: { type: String },
    price: { type: Number },
    quantity: { type: Number },
    apCode: { type: String },
    priceFlag: { type: String },
    bsFlag: { type: String },
    trade: { type: String },
  }))
  order: Record<string, string | number>;

  @Prop({ default: false })
  triggered: boolean;
}

export const MonitorSchema = SchemaFactory.createForClass(Monitor);

MonitorSchema 新增的 order 物件包含的欄位說明如下:

  • stockNo:股票代號。
  • buySell:買賣別。
  • price:委託價格。
  • quantity:委託數量。
  • apCode:盤別。如:整股、盤中零股、盤後定價、盤後零股、興櫃。
  • priceFlag:價格類型。如:限價、市價、平盤價、跌停價、漲停價。
  • bsFlag:訂單類別。如:ROD、IOC、FOK。
  • trade:交易類別。如:現股、融資、融券等。

完成 MonitorSchema 後,我們開啟 apps/monitor/src/monitor/monitor.repository.ts 檔案,實作 MonitorRepository 加入以下方法:

  • getOrders():取得所有觸價委託。
  • createOrder():建立一個觸價委託。
  • getOrder():取得一個觸價委託。
  • removeOrder():刪除一個觸價委託。

實作 MonitorRepository 方法如下:

import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { Monitor, MonitorDocument } from './monitor.schema';
import { CreateAlertDto } from './dto/create-alert.dto';
import { CreateOrderDto } from './dto/create-order.dto';

@Injectable()
export class MonitorRepository {
  constructor(
    @InjectModel(Monitor.name) private readonly model: Model<MonitorDocument>,
  ) {}

  ...

  async getOrders(): Promise<MonitorDocument[]> {
    return this.model.find({ order: { $exists: true } })
      .select('-__v -createdAt -updatedAt')
      .lean();
  }

  async createOrder(createOrderDto: CreateOrderDto): Promise<MonitorDocument> {
    const { order, ...monitorable } = createOrderDto;
    const monitor = { ...monitorable, order: JSON.parse(order) }
    return this.model.create(monitor);
  }

  async getOrder(id: string): Promise<MonitorDocument> {
    return this.model
      .findOne({ _id: id, order: { $exists: true } })
      .select('-__v -createdAt -updatedAt')
      .lean();
  }

  async removeOrder(id: string): Promise<void> {
    await this.model.deleteOne({ _id: id, order: { $exists: true } });
  }
}

createOrder() 方法中,接收參數 CreateOrderDto 是資料傳輸物件(Data Transfer Object)用來建立觸價委託設定。請在 apps/monitor/src/monitor/dto 目錄下建立 create-order.dto.ts 檔案,然後開啟該檔案並實作 CreateOrderDto 資料傳輸物件:

import { IsString, IsNumber, IsEnum, IsJSON } from 'class-validator';
import { MonitorType } from '../enums';

export class CreateOrderDto {
  @IsString()
  symbol: string;

  @IsEnum(MonitorType)
  type: MonitorType;

  @IsNumber()
  value: number;

  @IsJSON()
  order: string;
}

調整 Monitor Service

我們需要調整 MonitorService 新增處理觸價委託的方法。開啟 apps/monitor/src/monitor/monitor.service.ts,將 MonitorService 修改如下:

import { omit } from 'lodash';
import { DateTime } from 'luxon';
import { Redis } from 'ioredis';
import { Injectable, Inject, Logger, OnApplicationBootstrap, NotFoundException, ForbiddenException } from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import { InjectRedis } from '@liaoliaots/nestjs-redis';
import { InjectWebSocketClient } from '@fugle/realtime-nest';
import { InjectLineNotify, LineNotify } from 'nest-line-notify';
import { WebSocketClient } from '@fugle/realtime';
import { TRADER_SERVICE } from '@speculator/common';
import { MonitorRepository } from './monitor.repository';
import { MonitorDocument } from './monitor.schema';
import { CreateAlertDto } from './dto/create-alert.dto';
import { CreateOrderDto } from './dto/create-order.dto';

@Injectable()
export class MonitorService implements OnApplicationBootstrap {
  private readonly sockets = new Map<string, WebSocket>();

  constructor(
    @Inject(TRADER_SERVICE) private readonly traderService: ClientProxy,
    @InjectRedis() private readonly redis: Redis,
    @InjectWebSocketClient() private readonly client: WebSocketClient,
    @InjectLineNotify() private readonly lineNotify: LineNotify,
    private readonly monitorRepository: MonitorRepository,
  ) {}

  async onApplicationBootstrap() {
    // 取得所有未觸發的監控設定並進行監控
    const monitors = await this.monitorRepository.getMonitors();
    await Promise.all(monitors.map(monitor => this.makeMonitoring(monitor)))
  }

  ...

  async getOrders() {
    // 取得所有觸價委託
    return this.monitorRepository.getOrders();
  }

  async createOrder(createOrderDto: CreateOrderDto) {
    // 建立觸價委託並進行監控
    const monitor = await this.monitorRepository.createOrder(createOrderDto);
    await this.makeMonitoring(monitor);
    return omit(monitor.toJSON(), ['__v', 'createdAt', 'updatedAt']);
  }

  async removeOrder(id) {
    const monitor = await this.monitorRepository.getOrder(id);

    // 若不存在則回傳 404 錯誤
    if (!monitor) {
      throw new NotFoundException('order not found');
    }

    // 移除監控設定並刪除觸價委託
    await this.removeMonitor(monitor);
    return this.monitorRepository.removeOrder(id);
  }

  ...

  private async checkMatches(message: any) {
    // 非整股行情則結束函式
    if (message.data.info.type !== 'EQUITY') return;

    // 不包含最新成交價則結束函式
    if (!message.data.quote.trade) return;

    // 取出股票代號與最新成交價
    const { symbolId: symbol } = message.data.info;
    const { price } = message.data.quote.trade;

    // 按股票代號及最新成交價檢查匹配的監控設定 ID
    const matches = await Promise.all([
      this.redis.zrange(`monitors:${symbol}:price:gt`, '-inf', price, 'BYSCORE'),
      this.redis.zrange(`monitors:${symbol}:price:lt`, price, '+inf', 'BYSCORE'),
    ]).then(members => [].concat.apply([], members));

    // 若無滿足條件的監控設定則結束函式
    if (!matches.length) return;

    // 按監控設定 ID 取出匹配的監控設定
    const monitors = await this.redis.mget(matches)
      .then(results => results.map(data => JSON.parse(data)));

    for (const monitor of monitors) {
      await this.removeMonitor(monitor);  // 移除匹配的監控設定

      // 若監控設定包含 alert 則推播訊息
      if (monitor.alert) await this.sendAlert(monitor, message.data.quote);

      // 若監控設定包含 order 則下單委託
      if (monitor.order) await this.placeOrder(monitor, message.data.quote); // <--- 新增這行
    }
  }

  private async placeOrder(monitor: MonitorDocument, quote: any) {
    const { _id, symbol, order } = monitor;
    const time = DateTime.fromISO(quote.trade.at).toFormat('yyyy/MM/dd HH:mm:ss');

    // 設定推播訊息
    const message = [
      '',
      `<<觸價委託>>`,
      `股票代號: ${symbol}`,
      `成交價: ${quote.trade.price}`,
      `成交量: ${quote.total.tradeVolume}`,
      `時間: ${time}`,
    ].join('\n');

    // 透過 Trader Service 進行下單委託
    this.traderService.emit('place-order', order);

    // 透過 LINE Notify 推播訊息並將監控設定更新為已觸發
    await this.lineNotify.send({ message })
      .then(() => this.monitorRepository.triggerMonitor(_id))
      .catch((err) => Logger.error(err.message, err.stack, MonitorService.name));
  }
}

MonitorService 中,我們注入 ClientProxy 使用 TRADER_SERVICE 調用 Trader 應用程式的微服務。

「觸發委託」與「到價提醒」是基於相同的實現方式,差別在於觸發委託會確認 Monitor 監控物件是否包含 order 欄位;到價提醒則是檢查 alert 欄位。

MonitorServiceplaceOrder() 方法中,當觸價委託條件被觸發,Monitor Service 會向 Trader API 請求下單委託,並且透過 LINE Notify 將觸價委託訊息推播給使用者。

調整 Monitor API

完成 Monitor Service 後,我們要加入觸價委託設定至 Monitor API 中。

開啟 apps/monitor/src/monitor/monitor.controller.ts 檔案,實作 MonitorController 加入以下方法:

  • getOrders():取得所有觸價委託。
  • createOrder():建立觸價委託。
  • removeOrder():移除觸價委託。

調整 MonitorController 如下:

import { Controller, Get, Post, Delete, Body, Param, HttpCode } from '@nestjs/common';
import { MonitorService } from './monitor.service';
import { CreateAlertDto } from './dto/create-alert.dto';
import { CreateOrderDto } from './dto/create-order.dto';

@Controller('monitor')
export class MonitorController {
  constructor(private readonly monitorService: MonitorService) {}

  ...

  @Get('/orders')
  async getOrders() {
    return this.monitorService.getOrders();
  }

  @Post('/orders')
  async createOrder(@Body() createOrderDto: CreateOrderDto) {
    return this.monitorService.createOrder(createOrderDto);
  }

  @Delete('/orders/:id')
  @HttpCode(204)
  async removeOrder(@Param('id') id: string) {
    return this.monitorService.removeOrder(id);
  }
}

測試 Monitor API

完成 MonitorController 後,就可以測試 Monitor API 了。首先啟動 Monitor 應用程式:

$ npm start monitor

然後在終端機透過 curl 指令來測試以下 API endpoints:

  • GET /monitor/orders:建立觸價委託。
  • POST /monitor/orders:取得所有建立觸價委託。
  • DELETE /monitor/orders/:id:刪除觸價委託。

建立觸價委託

使用以下 curl 指令建立一個到價提醒,當台積電(2330)股價突破 500 元時,觸發下單委託以 500 元 ROD 現股買進 1 張:

$ curl --request POST \
    --url http://localhost:3000/monitor/orders \
    --header 'Content-Type: application/x-www-form-urlencoded' \
    --data symbol=2330 \
    --data type=price:gt \
    --data value=500 \
    --data 'order={"stockNo":"2330","buySell":"B","price":500,"quantity":1,"apCode":"1","priceFlag":"0","bsFlag":"R","trade":"0"}'

成功建立一個觸價委託,以 Monitor ID 6332f96d0d5a5cb977c849ba 為例:

{"symbol":"2330","type":"price:gt","value":"500","order":{"stockNo":"2330","buySell":"B","price":500,"quantity":1,"apCode":"1","priceFlag":"0","bsFlag":"R","trade":"0"},"triggered":false,"_id":"6332f96d0d5a5cb977c849ba"}

取得所有觸價委託

使用以下 curl 指令取得所有觸價委託:

$ curl --request GET \
  --url http://localhost:3000/monitor/orders

成功取得所有觸價委託:

[{"_id":"6332f96d0d5a5cb977c849ba","symbol":"2330","type":"price:gt","value":"500","order":{"stockNo":"2330","buySell":"B","price":500,"quantity":1,"apCode":"1","priceFlag":"0","bsFlag":"R","trade":"0"},"triggered":false}]

刪除觸價委託

使用以下 curl 指令刪除一個觸價委託,以 Monitor ID 6332f96d0d5a5cb977c849ba 為例:

$ curl --request DELETE \
    --url http://localhost:3000/monitor/orders/6332f96d0d5a5cb977c849ba

LINE Notify 訊息推播

完成後,我們就可以請求 Monitor API 新增觸價委託設定。當觸發下單委託條件時,Monitor Service 就會向 Trader API 發送下單請求,並透過 LINE Notify 發送推播訊息。當 Trader API 收到下單委託請求後,會立即執行下單動作,當收到委託回報時,Trader Service 會透過 LINE Notify 將回報訊息推播給使用者。

下圖是在交易日收盤後新增的觸價委託,設定條件是當台積電(2330)股價低於 450 元時,下單委託盤後定價買進 1 張台積電(2330)股票。

https://ithelp.ithome.com.tw/upload/images/20220928/20150150qsdHRysXT1.png

至此,我們已經示範如何實作自動化下單。透過富果行情與交易 API 的應用,可以依據事先設計好的進出場訊號,進行觸價委託設定。當條件觸發就可以立即讓程式代為執行下單委託。您可以根據自己的需求,自行加入其他觸發委託下單的條件,例如成交量、漲跌幅等,打造屬於自己的自動化交易系統。

本日小結

  • 瞭解 Trader 與 Monitor 應用伺服器的元件組成以及自動化下單的資料處理流程。
  • 調整 Trader 應用程式,加入基於 Redis 的微服務介面。
  • 調整 Monitor Service,新增處理觸價格委託的方法,並透過 LINE Notify 推播訊息。
  • 調整 Monitor API,使用者可以透過 HTTP API 建立觸價格委託設定。
  • 瞭解富果行情與交易 API 的整合應用,完成自動化下單程式。

Node.js 量化投資全攻略:從資料收集到自動化交易系統建構實戰
本系列文已正式出版為《Node.js 量化投資全攻略:從資料收集到自動化交易系統建構實戰》。本書新增了全新內容和實用範例,為你提供更深入的學習體驗!歡迎參考選購,開始你的量化投資之旅!
天瓏網路書店連結:https://www.tenlong.com.tw/products/9786263336070


上一篇
Day 27 - 程式交易系統:以富果交易 API 為例
下一篇
Day 29 - 長線獲利之道:定期定額投資系統
系列文
從 Node.js 開發者到量化交易者:打造屬於自己的投資系統31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言